Skip to content

Support integer range keys in constant arrays#4952

Open
staabm wants to merge 24 commits intophpstan:2.1.xfrom
staabm:array-keys
Open

Support integer range keys in constant arrays#4952
staabm wants to merge 24 commits intophpstan:2.1.xfrom
staabm:array-keys

Conversation

@staabm
Copy link
Contributor

@staabm staabm commented Feb 16, 2026

Summary

When assigning to a constant array with a union of constant string keys (e.g., 'a'|'b') or a finite integer range (e.g., int<1,5>), PHPStan previously degraded to a general ArrayType, losing the constant array shape information. This change produces a union of constant arrays instead, preserving precise type information.

For example, given @param array{foo: int} $a and @param int<1,5> $intRange:

$a[$intRange] = 256;
// Before: non-empty-array<'foo'|int<1, 5>, int>
// After:  array{foo: int, 1: 256}|array{foo: int, 2: 256}|array{foo: int, 3: 256}|array{foo: int, 4: 256}|array{foo: int, 5: 256}

Changes

  • src/Type/Constant/ConstantArrayType.php:
    • Modified setOffsetValueType() to detect when the offset type can be expanded to a finite set of constant keys with at least one new key, and produce a union of constant arrays
    • Added resolveFiniteScalarKeyTypes() private helper method that resolves offset types to individual constant key types from string unions and integer ranges
    • Limited expansion to CHUNK_FINITE_TYPES_LIMIT (5) new keys to avoid combinatorial explosion
    • Only expands string constant unions and IntegerRangeType (not integer constant unions like 0|1 which typically come from loop fixpoint analysis)
  • tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php: New regression test covering string union keys, integer range keys, infinite ranges, and existing keys
  • tests/PHPStan/Analyser/nsrt/constant-array-type-set.php: Updated test expectation for int<0, 4> offset on 3-element array to reflect more precise union result
  • CLAUDE.md: Added documentation about the new union expansion behavior

Root cause

ConstantArrayTypeBuilder::setOffsetValueType() handles non-constant offset types by expanding them to constant scalars and checking if all match existing keys. When some keys were new (not in the array), it fell through to a degradation path that produced a general ArrayType. The fix intercepts this at the ConstantArrayType::setOffsetValueType() level, creating a union of arrays for each possible key before the builder can degrade.

The expansion is carefully limited to avoid regressions:

  • Only string constant unions and IntegerRangeType trigger expansion (not integer constant unions which are common in loop analysis)
  • At most CHUNK_FINITE_TYPES_LIMIT (5) new keys are expanded
  • When all keys already exist in the array, the builder handles it directly without expansion

Test

Added tests/PHPStan/Analyser/nsrt/set-constant-union-offset-on-constant-array.php with four test cases:

  1. doFoo: String union offset 'a'|'b' on array{foo: int} → union of two constant arrays
  2. doBar: Integer range int<1,5> on array{foo: int} → union of five constant arrays
  3. doInfiniteRange: Infinite range int<0, max> → falls back to general array (no expansion)
  4. doExistingKeys: Range int<0,1> on array{0: 'a', 1: 'b'} → handled by builder directly

Fixes phpstan/phpstan#14129

Closes phpstan/phpstan#9907

@phpstan-bot
Copy link
Collaborator

You've opened the pull request against the latest branch 2.2.x. PHPStan 2.2 is not going to be released for months. If your code is relevant on 2.1.x and you want it to be released sooner, please rebase your pull request and change its target to 2.1.x.

@staabm staabm changed the base branch from 2.2.x to 2.1.x February 16, 2026 11:53
staabm and others added 12 commits February 16, 2026 12:57
- When setting a constant array offset with a finite union of constant
  string types or a finite IntegerRangeType, produce a union of constant
  arrays instead of degrading to a general array type
- Added resolveFiniteScalarKeyTypes() helper on ConstantArrayType to
  extract constant key types from string unions and integer ranges
- Limited expansion to CHUNK_FINITE_TYPES_LIMIT (5) keys to avoid
  combinatorial explosion in loop fixpoint analysis
- Excluded integer constant unions (e.g., 0|1 from loops) to prevent
  regression in loop variable tracking
- Updated constant-array-type-set.php test expectations for more precise
  results with int<0,4> range offsets
- Remove unnecessary toArrayKey() calls in resolveFiniteScalarKeyTypes()
- Use ConstantArrayTypeBuilder instead of recursion in setOffsetValueType()
- Remove CLAUDE.md changes
- Add test case for int<0, 5>|int<10, 15> union of integer ranges

Co-authored-by: Markus Staab <staabm@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Automated fix attempt 1 for CI failures.
- Remove bug-14129.php (already covered by set-constant-union-offset-on-constant-array.php)
- Move bug-7978.php from NSRT to CallMethodsRuleTest
- Add de-duplication for overlapping integer ranges in resolveFiniteScalarKeyTypes()
- Add test for overlapping ranges (int<0,3>|int<2,4>)

Co-authored-by: Markus Staab <staabm@users.noreply.github.com>
@staabm staabm marked this pull request as ready for review February 16, 2026 12:41
@phpstan-bot
Copy link
Collaborator

This pull request has been marked as ready for review.

Comment on lines 704 to 736
$scalarKeyTypes = $this->resolveFiniteScalarKeyTypes($offsetType);
// turn into tagged union for more precise results
if (
$scalarKeyTypes !== null
&& count($scalarKeyTypes) >= 2
&& count($scalarKeyTypes) <= InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT
) {
$hasNewKey = false;
foreach ($scalarKeyTypes as $scalarKeyType) {
$existingKeyFound = false;
foreach ($this->keyTypes as $existingKeyType) {
if ($existingKeyType->getValue() === $scalarKeyType->getValue()) {
$existingKeyFound = true;
break;
}
}
if (!$existingKeyFound) {
$hasNewKey = true;
break;
}
}

if ($hasNewKey) {
$arrayTypes = [];
foreach ($scalarKeyTypes as $scalarKeyType) {
$builder = ConstantArrayTypeBuilder::createFromConstantArray($this);
$builder->setOffsetValueType($scalarKeyType, $valueType);
$arrayTypes[] = $builder->getArray();
}

return TypeCombinator::union(...$arrayTypes);
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in a perfect world, I think this logic should be part of ConstantArrayTypeBuilder->setOffsetValueType

@staabm staabm force-pushed the array-keys branch 2 times, most recently from c81e7f1 to f3e2700 Compare February 16, 2026 15:35
if ($offsetType !== null) {
$scalarKeyTypes = $offsetType->toArrayKey()->getConstantStrings();
if (count($scalarKeyTypes) === 0) {
$integerRanges = TypeUtils::getIntegerRanges($offsetType);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're calling getIntegerRanges on offsetType but previously you worked with

$offsetType->toArrayKey()

Shouldn't you work every where with $offsetType->toArrayKey() ?

that could have impact for things like int<0, 2>|'3' maybe ?

}

// turn into tagged union for more precise results
if (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unclear to me why

  • We're working only with constantStrings (getConstantStrings)
  • We're adding int range only when there is no string

What about @param 1|2 $key ?
And what about @param '1'|2 $key ?

Also the $existingKeyType->getValue() === $scalarKeyType->getValue() check might miss some 1 === '1'.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants